/*
 * Copyright (c) 2011 Obix Labs Limited
 * Redistribution and use in source and binary forms, 
 * with or without modification, are permitted provided 
 * that the following conditions are met:
 * 
 * 		Redistribution of source code must retain the above 
 * 		copyright notice, this list of conditions and the 
 * 		following disclaimer.
 *
 * 		Redistribution in binary form must reproduce the 
 * 		above copyright notice, this list of conditions 
 * 		and the following disclaimer in the documentation 
 * 		and/or other materials provided with the distribution.
 * 
 * 	THIS SOFTWARE IS PROVIDED "AS IS," WITHOUT A WARRANTY OF 
 * 	ANY KIND. ALL EXPRESS OR IMPLIED CONDITIONS, REPRESENTATIONS 
 * 	AND WARRANTIES, INCLUDING ANY IMPLIED WARRANTY OF MERCHANTABILITY, 
 * 	FITNESS FOR A PARTICULAR PURPOSE OR NON-INFRINGEMENT, 
 * 	ARE HEREBY EXCLUDED. OBIX LABS LIMITED ("Obix Labs") AND ITS 
 * 	LICENSORS SHALL NOT BE LIABLE FOR ANY DAMAGES SUFFERED BY LICENSEE 
 * 	AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THIS SOFTWARE OR 
 * 	ITS DERIVATIVES. IN NO EVENT WILL Obix Labs OR ITS LICENSORS BE 
 * 	LIABLE FOR ANY LOST REVENUE, PROFIT OR DATA, OR FOR DIRECT, 
 * 	INDIRECT, SPECIAL, CONSEQUENTIAL, INCIDENTAL OR PUNITIVE 
 * 	DAMAGES, HOWEVER CAUSED AND REGARDLESS OF THE THEORY OF 
 * 	LIABILITY, ARISING OUT OF THE USE OF OR INABILITY TO USE THIS 
 * 	SOFTWARE, EVEN IF Obix Labs HAS BEEN ADVISED OF THE POSSIBILITY OF 
 * 	SUCH DAMAGES.
 */
package com.obixlabs.commons.zip;

import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.CheckedInputStream;
import java.util.zip.Checksum;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

import com.obixlabs.commons.ObixException;
import com.obixlabs.commons.ObixRuntimeException;
import com.obixlabs.commons.io.FileUtilities;
import com.obixlabs.commons.io.IOResourceCloser;
import com.obixlabs.commons.io.StreamReadWriteUtilities;

/**
 * <p>
 * A utility class for reading and parsing the contents of a ZIP archive.
 * </p>
 */
public class ZIPReader
{
	/**
	 * <p>
	 * The {@link CompressedItem contents} of the ZIP archive.
	 * </p>
	 */
	private List<CompressedItem> contents;
	
	/**
	 * <p>
	 * Constructs an instance to parse the given ZIP data.
	 * </p>
	 * @param zipData	The zip archive to read.
	 * @throws ObixException	If an error occurs parsing 
	 * the ZIP data.
	 */
	public ZIPReader(	byte[] zipData) throws ObixException 
	{ this(zipData,null);}

	/**
	 * <p>
	 * Similar to {@link #ZIPReader(byte[])}, except that 
	 * it allows the caller to specify the <code>checksum</code> 
	 * instance that is used for error checking.
	 * </p>
	 * 
	 * @param zipData	The zip archive to read.
	 * @param checksum The checksum to be used for error checking.
	 * @throws ObixException	If an error occurs parsing the ZIP data.
	 */	
	public ZIPReader(byte[] zipData, Checksum checksum) 
					throws ObixException
	{
		this.contents = new ArrayList<CompressedItem>	();	
		initialise(zipData, checksum);
	}
	
	/**
	 * <p>
	 * Parses the contents of the given ZIP file.
	 * </p>
	 * @param zipFile	The path to the ZIP file.
	 * @throws ObixException	If an error occurs parsing the ZIP file.
	 */	
	public ZIPReader(File zipFile) throws ObixException
	{ this(FileUtilities.readFileContentsAsBytes(zipFile)); }

	/**
	 * <p>
	 * Similar to {@link #ZIPReader(File)}, except that it allows the 
	 * caller to specify the <code>checksum</code> instance that 
	 * is used for error checking.
	 * </p> 
	 * 
	 * @param zipFile	The path to the ZIP file.
	 * @param checksum The checksum to be used for error checking.
	 * @throws ObixException	If an error occurs parsing the ZIP file.
	 */
	public ZIPReader(File zipFile, Checksum checksum) throws ObixException
	{ 
		this(FileUtilities.
						readFileContentsAsBytes(zipFile), checksum);
	}
	
	/**
	 * <p>
	 * Parses the contents of the given stream as a ZIP archive.
	 * </p>
	 * 
	 * @param inputStream	The stream from which to read the 
	 * ZIP archive.
	 * @throws ObixException	If an error occurs parsing the ZIP file.
	 */
	public ZIPReader(	InputStream inputStream) throws ObixException
	{  
		this(StreamReadWriteUtilities.
						readStreamContentsAsBytes(inputStream)); 
	}
	
	
	/**
	 * <p>
	 * Similar to {@link #ZIPReader(InputStream)}, except that it allows the 
	 * caller to specify the <code>checksum</code> instance that 
	 * is used for error checking.
	 * </p> 
	 * 
	 * @param inputStream	The stream from which to read the ZIP archive.
	 * @param checksum The checksum to be used for error checking.
	 * @throws ObixException	If an error occurs parsing the ZIP file.
	 */	
	public ZIPReader(	InputStream inputStream, 
											Checksum checksum) throws ObixException
	{ 
		this(StreamReadWriteUtilities.
						readStreamContentsAsBytes(inputStream), checksum);
	}
	
	/**
	 * <p>
	 * Returns the contents retrieved from the ZIP archive. 
	 * </p>
	 * <p>
	 * If the archive contains a folder structure, the returned 
	 * list will, in effect, be the directory tree of the archive. Each
	 * node (directory) will be an instance of {@link CompressedDataFolder},
	 * while files (leaves) will be encapsulated by a {@link CompressedData}
	 * instance. The elements of the <code>List</code> can be tested 
	 * by invoking the {@link CompressedItem#isFolder(CompressedItem)}
	 * method to determine if they are directories or files.
	 * </p>
	 * 
	 * @return	The contents retrieved from the ZIP archive.
	 */
	public List<CompressedItem>  getContents()
	{ return Collections.unmodifiableList(contents); }

	/**
	 * <p>
	 * Internal initialisation delegate. Parses the ZIP data 
	 * and extracts its contents into the {@link #contents
	 * internal contents list}.
	 * </p>
	 * 
	 * @param zipData	The ZIP data.
	 * @param checksum	The checksum to be used for 
	 * error validation.
	 * 
	 * @throws ObixException	If an error occurs parsing the ZIP file.	
	 */
	private void initialise(	byte[] zipData, Checksum checksum) 
													throws ObixException
	{
		ZipInputStream zipStream = null;
		
		try
		{
			Map<String, Long> entrySizes 
				= prescanStreamForEntrySizes(zipData, checksum);
			
			zipStream = createInputStream(zipData, checksum);
			
			ZipEntry zipEntry = zipStream.getNextEntry();
			readEntry(zipEntry, zipStream, null,entrySizes);
		}
		catch (IOException io_exce)
		{ throw ObixException.wrapException(io_exce); }
		finally { IOResourceCloser.close(zipStream); }
	}

	
	/**
	 * <p>
	 * Recursive method which reads all the files (and sub-directories) from 
	 * a given folder starting with the specified entry.
	 * </p>
	 * 
	 * @param entry	The first entry to be read from the ZIP folder.
	 * @param zipStream	The stream from which the entry, and subsequent 
	 * entries, will be parsed. 
	 * @param parent	The folder whose entries are being parsed. In the case
	 * of items in the archive root, this will be null.
	 * @param entrySizes	The pre-scanned sizes of the entries in the 
	 * ZIP archive.
	 * 
	 * @throws IOException	If an error occurs parsing the ZIP data.
	 */
	private void readEntry(	ZipEntry entry,  ZipInputStream zipStream, 
														CompressedDataFolder parent, 
														Map<String, Long> entrySizes) 
														throws IOException
	{	
		CompressedItem item;
		CompressedDataFolder dataFolder;
		byte[] readBuffer;
		int entrySize;
		
		while (entry!=null)
		{
			if (!entry.isDirectory())
			{
				entrySize = resolveEntrySize(entry, entrySizes);
				readBuffer = new byte[entrySize];
				zipStream.read(readBuffer);
				
				item = new CompressedData(entry.getName(), 
																readBuffer);
				if (parent!=null) parent.add(item);
				else contents.add(item);
				
				zipStream.closeEntry();
				entry = zipStream.getNextEntry();
			}
			else
			{
				dataFolder = new CompressedDataFolder(entry.getName());
				
				if (parent!=null) parent.add(dataFolder);
				else contents.add(dataFolder); 
					
				entry = zipStream.getNextEntry();
				readEntry(	entry, zipStream, 
										dataFolder, entrySizes);
				
				zipStream.closeEntry();
				
				entry = zipStream.getNextEntry();
			}
		}
	}

	/**
	 * <p>
	 * Resolves the size of the given entry using the map of 
	 * pre-scanned sizes.
	 * </p>
	 * 
	 * @param entry	The entry whose size is to be resolved.
	 * @param entrySizes	The map of pre-scanned entry sizes.
	 * @return	The entry's size.
	 */
	private int resolveEntrySize(	ZipEntry entry, Map<String, Long> entrySizes)
	{
		int result;		
		if (entrySizes.containsKey(entry.getName()))
			result = entrySizes.get(entry.getName()).intValue();
		else throw new ObixRuntimeException(
				"Library fault: Unable to determine size of entry " + 
				entry.getName());		
		return result;
	}
	
	
	/**
	 * <p>
	 * Pre-scans the given zip archive to determine the sizes of
	 * the individual entries in the archive.
	 * </p>
	 * 
	 * @param zipData	The ZIP archive.
	 * @param checksum	Checksum to be used for error checking.
	 * @return	A map containing the sizes of the entries in the ZIP file.
	 * @throws IOException	If an error occurs parsing the ZIP data. 
	 */
	private Map<String, Long> prescanStreamForEntrySizes(
						byte[] zipData, Checksum checksum) throws IOException
	{
		Map<String, Long> result = new HashMap<String, Long>();
		
		ZipInputStream zipStream = 
				createInputStream(zipData, checksum);
		ZipEntry entry = zipStream.getNextEntry();
		scanFolderEntrySizes(zipStream, result, entry);
		
		return result;
	}
	
	
	/**
	 * <p>
	 * Recursively extracts the sizes of entries in the current ZIP 
	 * folder, starting with the given ZIP entry.  
	 * </p>
	 * 
	 * @param zipStream	The stream from which to read the ZIP entry and
	 * subsequent entries.
	 * @param entrySizes	The map in which to store the extracted entry sizes,
	 * keyed by the entry names.
	 * @param entry	The first entry in the folder whose entry sizes are 
	 * being extracted.
	 * @throws IOException	If an error occurs reading entries from the 
	 * stream.
	 */
	private void scanFolderEntrySizes(	ZipInputStream zipStream, 
										Map<String, Long> entrySizes,
										ZipEntry entry) throws IOException
	{
		ZipEntry zipEntry = entry;
		while (zipEntry!=null)
		{
			if (!zipEntry.isDirectory())
			{
				zipStream.closeEntry();
				entrySizes.put(zipEntry.getName(), 
									zipEntry.getSize());
				
				zipEntry = zipStream.getNextEntry();
			}
			else
			{
				zipEntry = zipStream.getNextEntry();
				scanFolderEntrySizes(zipStream, entrySizes, zipEntry);
				
				zipStream.closeEntry();				
				zipEntry = zipStream.getNextEntry();
			}
		}
	}
	
	/**
	 * <p>
	 * Creates a ZIP input-stream to parse the given ZIP archive.
	 * </p>
	 * 
	 * @param zipData	The ZIP archive (in byte form).
	 * @param checksum	The checksum to be used for error checking.
	 * @return A ZIP stream to parse the given archive.
	 */
	private ZipInputStream createInputStream(
											byte[] zipData,
											Checksum checksum)
	{
		ZipInputStream result;
		ByteArrayInputStream byteArrayStream = 
			new ByteArrayInputStream(zipData);		
		if (checksum!=null)
		{
			CheckedInputStream checkedInputStream = 
				new CheckedInputStream(byteArrayStream,checksum);
			result = new ZipInputStream(checkedInputStream);
		}
		else result = new ZipInputStream(byteArrayStream);
		return result;
	}
}//end class def